Skip to content

feat(runtime): rooted wrapper lifetime mode - #559

Draft
edusperoni wants to merge 2 commits into
feat/wrapper-lifetime-diagnosticsfrom
feat/wrapper-lifetime-rooted
Draft

edusperoni wants to merge 2 commits into
feat/wrapper-lifetime-diagnosticsfrom
feat/wrapper-lifetime-rooted

Conversation

@edusperoni

@edusperoni edusperoni commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Second of the stacked wrapper-lifetime series, on top of #558. Adds a config flag for how ObjC object wrappers live, and a rooted mode that does not rely on resurrecting finalizers. The default stays resurrecting, so nothing changes unless an app opts in.

Current behavior

  • An ObjC object's wrapper has a kFinalizer weak callback. When the JS object becomes unreachable, the finalizer releases the native object, unless the object is a JS-extended instance that native code still retains. In that case the finalizer refuses and the wrapper is resurrected.
  • Everything reachable only through a resurrected wrapper is queued in the same GC and released. A natively owned holder therefore comes back holding released objects (see feat(runtime): wrapper lifetime diagnostics and released-object arguments #558), and anything else it reaches can be torn down under it.
  • Plain (non-extended) wrappers are never protected. When native code still owns the object, their JS properties are lost.

New behavior

Config. The app config takes "wrapperLifetime": { "mode": "resurrecting" | "rooted", "scope": "extended" | "all" }. It is read once per process; unknown values log a warning and fall back to the defaults (resurrecting, extended). ns:runtime exposes both values as read-only config keys, wrapperLifetimeMode and wrapperLifetimeScope.

rooted mode.

  • A wrapper never gets a kFinalizer callback. Its handle is a strong root while something other than the runtime retains its object, and a phantom (kParameter) weak handle otherwise.
  • Deciding strength: a major-GC prologue compares each tracked object's -retainCount against the retains the runtime itself holds, counted across isolates. It uses the first non-JS -retainCount implementation, since JS cannot run in a GC prologue. Wrappers are born strong, and are made strong again whenever they cross the JS/native boundary: returned from native code, or passed as a receiver or argument.
  • Releasing: a phantom callback only queues the registration. The native object is released later on the runtime's thread, from the event loop or on entry to the next native call, where a JS-implemented -dealloc may run.
  • Scope: "extended" roots only instances of JS-extended classes. "all" roots every ObjC wrapper except blocks and tagged pointers.
  • The extended classes' retain/release swizzle is not installed in this mode.

A JS-unreachable holder that native code owns therefore stays rooted together with everything it references, so nothing it reaches is released under it. This includes WeakRef targets and WeakMap entries keyed on it.

Developer contract under rooted. It follows the ARC rules: a native object that holds JS which references it back is a cycle, and stays alive until one side lets go. Break such back references with a WeakRef. Later PRs in the series add a portable canary mode alongside these two.

Other changes.

  • Cache readers (FindCachedInstance, CreateJsWrapper, the method-callback self-wrap sites) drop an Instances entry whose handle is empty, because its release is still queued, instead of handing out a dead wrapper.
  • Teardown releases whatever the queue still holds. Caches::ExistingStateFor<T> reads a state slot after InvalidateIsolate for this.
  • getWrapperLifetimeStats() gains promotions, demotions and queuedReleases.

Docs: docs/ns-builtin-modules.md, docs/knowledge/v8-resurrecting-finalizers.md, types/ns-runtime.d.ts.

Testing

  • New WrapperLifetimeModesTests.js, mode-aware: natively owned extended objects keep their JS state and everything it references; WeakMap/WeakRef survive under all; JS-only objects collect; release after native code lets go; WeakRef-broken cycles collect; the ListView shape (a WeakRef never hands back a view whose native objects were released); a JS-implemented retainCount; explicit release; counters.
  • Full suite, 0 failures (1890 specs), in each of resurrecting/extended, rooted/all and rooted/extended. ASan, rooted/all: 0 failures, no reports. WrapperLifetimeModesTests.js names the exercised mode in a spec title, and each rooted run's log was checked for it (an earlier round had set the config under the wrong key and silently ran the default mode). CI runs the default mode; the rooted runs inject the config into TestRunner/app/package.json locally.

Performance

The default resurrecting mode only gains:

  • an inline load of the mode global and a type test in Escape on native calls, argument writes and cache hits;
  • one relaxed atomic load per native call (the release-queue gate);
  • an emptiness test on Instances hits.

The resurrecting-only retain/release swizzle is installed as before. Release A/B against #558, simulator, 10 alternating runs per side (order reversed for the second five), all bench specs. Medians:

Case #558 this PR
NSObject.alloc().init() (wrapper churn) 1116 ns 1130 ns
obj.isEqual(obj) 120 ns 121 ns
UIScreen.mainScreen.bounds.size.width (full chain) 674 ns 687 ns
NSData.dataWithData(Uint8Array 64 B) 1139 ns 1181 ns
small.isEqualToData(fresh on-heap Uint8Array(16)) 924 ns 865 ns
Full GC disposing JS-only wrappers, per wrapper 2741 ns 2738 ns

Every other case is within ±3%. The largest moves go both ways, and one (cached struct: bounds.size, 25 → 27 ns) is on a path this PR does not touch, so they read as layout and run noise rather than a cost.

V8 dependency

rooted itself needs no V8 patch: it uses phantom callbacks and GC prologue callbacks only. The resurrecting default still needs v8_resurrecting_finalizers.patch.

Adds the app config "wrapperLifetime": { "mode", "scope" }, read once
per process. "resurrecting" (default) is the existing behaviour. "rooted"
stops relying on resurrecting finalizers for ObjC object wrappers:

- An adopted wrapper's handle is a phantom (kParameter) weak handle
  instead of a kFinalizer one. Its first pass only queues the
  registration; the native object is released later, on the runtime's
  thread, from the event loop or on entry to a native call.
- A wrapper in scope is a strong root while something other than the
  runtime retains its object. A major-GC prologue compares -retainCount
  (the first non-JS implementation) against the runtime's own retains,
  counted process-wide, and promotes or demotes. Wrappers are born strong
  and made strong again when they cross the JS/native boundary.
- "scope": "extended" (default) roots only JS-extended instances; "all"
  roots every ObjC wrapper except blocks and tagged pointers.
- The extended classes' retain/release swizzle is not installed in
  "rooted".

A JS-unreachable holder that native code owns therefore stays rooted
with everything it references, so nothing it reaches is released under
it (the ListView shape in WrapperLifetimeModesTests.js).

Cache readers drop an Instances entry whose handle is empty (its release
is still queued) instead of handing out a dead wrapper. Teardown releases
whatever the queue holds through Caches::ExistingStateFor, since the
isolate is already invalidated by then.

ns:runtime gains the read-only config keys wrapperLifetimeMode and
wrapperLifetimeScope, and the promotions, demotions and queuedReleases
counters.
@coderabbitai

coderabbitai Bot commented Nov 12, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@edusperoni
edusperoni added this pull request to stack #562 November 13, 2026 00:11

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant